Implementing Optional Update Functionality in ASP.NET Core Web API
TLDR
- Use a custom
OptionalValue<T>struct to precisely distinguish between "do not update field" and "update to null." - Implement
JsonConverterto handle[FromBody]requests, simplifying the JSON serialization format. - Implement
ModelBinderto handle[FromForm]requests, resolving form data binding issues. - Implement
IModelValidatorto ensure Data Annotation validation mechanisms work correctly. - Adjust Swagger documentation via
ISchemaFilterandIOperationFilterto ensure the API documentation displays the correct data structure.
In RESTful API PATCH operations, a common pain point is the inability to distinguish between "do not update this field" and "update the field to null." If you use null as the basis for judgment, you cannot distinguish the original state for struct types like DateTime or int. This solution uses a custom OptionalValue<T> type, allowing the backend to explicitly identify whether the frontend has passed the field.
Optional Property Type Design
When do you encounter this problem: When an API needs to support partial updates and must distinguish between "field not passed (ignore)" and "field passed as null (update)."
We use readonly record struct to define OptionalValue<T>, utilizing the HasValue property to mark whether the field has been assigned a value.
public readonly record struct OptionalValue<T> {
private readonly T value;
public OptionalValue(T value) {
HasValue = true;
this.value = value;
}
public static OptionalValue<T> Empty() => new();
[ValidateNever]
public bool HasValue { get; }
[ValidateNever]
public T Value {
get {
if (!HasValue) {
throw new InvalidOperationException("OptionalValue object must have a value.");
}
return value;
}
}
public static implicit operator OptionalValue<T>(T value) {
return new OptionalValue<T>(value);
}
public static explicit operator T(OptionalValue<T> value) {
return value.Value;
}
}FromBody JsonConverter Implementation
When do you encounter this problem: When using [FromBody] to receive JSON data, default serialization treats OptionalValue<T> as a complex object, forcing the frontend to pass a format like {"hasValue": true, "value": "..."}.
By using a custom JsonConverter, we can simplify the JSON to pass values directly, such as {"string1": "Value"}.
Custom JsonConverter and Factory
public class OptionalValueConverter<T> : JsonConverter<OptionalValue<T>> {
public override OptionalValue<T> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) {
if (reader.TokenType == JsonTokenType.None) {
return OptionalValue<T>.Empty();
} else {
T? value = JsonSerializer.Deserialize<T>(ref reader, options);
if (value is null && typeof(T).IsValueType && Nullable.GetUnderlyingType(typeof(T)) is null) {
throw new JsonException($"Null value is not allowed for non-nullable type {typeof(T)}.");
}
return new OptionalValue<T>(value!);
}
}
public override void Write(Utf8JsonWriter writer, OptionalValue<T> value, JsonSerializerOptions options) {
if (value.HasValue) {
JsonSerializer.Serialize(writer, value.Value, options);
}
}
}
public class OptionalValueJsonConverterFactory : JsonConverterFactory {
public override bool CanConvert(Type typeToConvert) =>
typeToConvert.IsGenericType && typeToConvert.GetGenericTypeDefinition() == typeof(OptionalValue<>);
public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) {
Type type = typeToConvert.GetGenericArguments()[0];
Type converterType = typeof(OptionalValueConverter<>).MakeGenericType(type);
return Activator.CreateInstance(converterType) as JsonConverter;
}
}FromForm ModelBinder Implementation
When do you encounter this problem: When using [FromForm] to receive form data, ASP.NET Core cannot correctly bind form fields to generic structs by default.
We implement IModelBinder to handle the parsing of form data, correctly mapping the key=value format to OptionalValue<T>.
public class OptionalValueModelBinder<T> : IModelBinder {
public Task BindModelAsync(ModelBindingContext bindingContext) {
ValueProviderResult valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
if (valueProviderResult == ValueProviderResult.None) {
bindingContext.Result = ModelBindingResult.Success(OptionalValue<T>.Empty());
return Task.CompletedTask;
}
string? valueStr = valueProviderResult.FirstValue;
Type targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T);
try {
TypeConverter converter = TypeDescriptor.GetConverter(targetType);
object? convertedValue = converter.CanConvertFrom(typeof(string))
? converter.ConvertFrom(valueStr!)
: Convert.ChangeType(valueStr, targetType);
bindingContext.Result = ModelBindingResult.Success(new OptionalValue<T>((T)convertedValue!));
} catch {
bindingContext.ModelState.AddModelError(bindingContext.ModelName, $"The value '{valueStr}' is invalid.");
}
return Task.CompletedTask;
}
}Handling Data Validation
When do you encounter this problem: When DTO properties use Data Annotations like [Required] or [Range], the default validator cannot penetrate OptionalValue<T> to check the internal value.
We implement IModelValidator, which only validates the internal Value when HasValue is true.
public class OptionalValueValidator<T> : IModelValidator {
private readonly ValidatorItem validatorItem;
public OptionalValueValidator(ValidatorItem validatorItem) => this.validatorItem = validatorItem;
public IEnumerable<ModelValidationResult> Validate(ModelValidationContext context) {
if (context.Model is OptionalValue<T> optionalValue && optionalValue.HasValue) {
if (validatorItem.ValidatorMetadata is ValidationAttribute attribute) {
if (!attribute.IsValid(optionalValue.Value)) {
yield return new ModelValidationResult("", attribute.FormatErrorMessage(context.ModelMetadata.GetDisplayName()));
}
}
}
}
}Handling Swagger Schema
When do you encounter this problem: Due to the use of custom Converters and Binders, the automatically generated Swagger documentation displays an incorrect structure (e.g., showing the hasValue field).
Through ISchemaFilter and IOperationFilter, we can force Swagger to display the correct type and parameter format.
OptionalValueSchemaFilter: Simplifies the[FromBody]schema to the original type.OptionalValueOperationFilter: Corrects the[FromForm]parameter name fromProperty.ValuetoProperty.
Conclusion and Recommended Practices
- Encapsulation via
OptionalValue<T>effectively resolves the ambiguity of partial API updates. - Ensure that
JsonConverterFactory,ModelBinderProvider, andModelValidatorProviderare registered in order inProgram.cs. - It is recommended to use this pattern consistently across the project for
PATCHrequests to maintain API interface consistency.
Changelog
- 2024-10-21 Initial document created.
